Skip to content

feat: youtube-oauth-egress/#83 - #87

Merged
chaeyn merged 6 commits into
mainfrom
feat/youtube-oauth-egress/#83
Aug 10, 2026
Merged

feat: youtube-oauth-egress/#83#87
chaeyn merged 6 commits into
mainfrom
feat/youtube-oauth-egress/#83

Conversation

@itzjb

@itzjb itzjb commented Aug 10, 2026

Copy link
Copy Markdown
Member

변경 내용

송출 대상이 서버 전역 환경변수 하나로 고정돼 있어 모든 세션이 운영자 채널 하나로만 나갔고, 동시 세션이 둘 이상이면 같은 키로 충돌했어요. 사용자가 자기 YouTube 계정을 연결하고 명시적으로 요청할 때만 자기 채널로 송출되도록 바꿨어요.

  • 송출 계정 테이블과 마이그레이션을 추가했어요. 로그인 신원과 분리한 것은 로그인에 쓰는 Google 계정과 방송할 채널의 계정이 다를 수 있기 때문이에요.
  • 계정 연결은 클라이언트가 구글에서 받은 인가 코드를 서버로 넘기는 방식이에요. 기존 로그인이 이미 같은 형태이고, 리다이렉트가 없으면 콜백 경로와 state 관리가 필요 없어져요.
  • 방송 준비와 종료를 라이프사이클 인터페이스로 두고 YouTube 구현을 붙였어요. 치지직은 방송 시작·종료 API가 없어서 키 조회 수준으로 추상화하면 두 플랫폼이 한 구조에 들어가지 않아요.
  • handleStartStream 501 스텁을 실배선하고 생성 이후 갱신되지 않던 StreamState를 채웠어요. 뷰어 송출은 그대로 둔 채 플랫폼 송출만 끄는 것도 가능해졌어요.
  • 전역 YOUTUBE_STREAM_KEY를 제거했어요. ingest 주소도 API 응답값을 쓰므로 하드코딩 상수가 사라졌고, 그 과정에서 평문 RTMP 대신 RTMPS로 전환돼요.
  • 브라우저 클라이언트에 연결 버튼을 추가했어요. 로그인 흐름은 그대로예요.

확인 방법

go vet ./..., go test -count=1 ./..., go test -race -count=1 ./... 를 캐시 없이 실행해 전 패키지 통과를 확인했어요. PostgreSQL 게이트 테스트(TestPostgresStreamingAccountUpsert)는 실제 PostgreSQL 16에 붙여 실행했고, GORM AutoMigrate와 versioned SQL이 만드는 스키마가 컬럼·인덱스·제약까지 일치하는지 두 DB를 만들어 대조했어요.

로컬에서 브라우저로 전 구간을 완주했어요. 이메일 로그인 → 계정 연결(구글 팝업) → 카메라 연결 → 송출 시작 → 실제 YouTube 라이브 확인 → 중지까지 진행했고, 중지 후에도 세션과 뷰어 트랙이 살아 있는 것을 확인했어요. 스트림 키는 암호문으로만 저장되고 로그에는 마스킹돼 나와요.

실측으로 확정한 동작

구현 전에 API를 손으로 호출해 확인한 값이에요. 문서와 달랐던 것이 네 건 있어서 코드에 반영했어요.

  • 자동 시작을 켜면 전환 호출 없이 송출 시작 13.7초 만에 라이브가 되고, 송출을 멈추면 57.6초 뒤 자동 종료돼요. 상태를 폴링하며 전환을 재시도하는 처리가 필요 없어요.
  • 라이브가 활성화되지 않은 계정은 livePermissionBlocked로 거절돼요. 문서 기준으로 예상했던 insufficientPermissions가 아니에요. 활성화 전후로 같은 호출을 실행해 확인했어요.
  • 웹 팝업이 준 인가 코드는 교환 시 redirect_uri=postmessage를 요구해요. 생략하면 Missing parameter: redirect_uri로 거절돼요. 네이티브 SDK 코드는 생략이 문서 기준이라 출처별로 나눴어요.
  • Testing 게시 상태에서는 리프레시 토큰이 7일 만료이고 갱신해도 연장되지 않아요. Production으로 전환한 뒤 재연결하면 만료 필드 자체가 없어져요.
  • rtmpsIngestionAddress에는 포트 표기가 없어요. URL 조합은 응답값을 그대로 써요.

아직 확인하지 못한 것

  • 네이티브 SDK가 발급한 인가 코드로 실제 교환해 본 적이 없어요. 앱에 연결 버튼이 아직 없어서예요. 출처별 매핑만 바꾸면 되도록 분리해 두었고, 앱이 준비되면 코드 하나로 확인할 수 있어요.
  • 방송 준비가 끝난 직후 송출 전에 세션이 사라지면 채널에 시작되지 않은 예약 방송이 남을 수 있어요. 자동 종료는 송출이 시작된 적 있는 방송만 정리해요.
  • Android는 현재 로그인에 쓰는 API로는 스코프를 요청할 수 없어서 별도 통합이 필요해요.

관련

  • 선행 fix: 트랙 교체 시 RTMP egress가 중단되어 송출이 단절됨 #84 (트랙 교체 시 egress 중단) — 명시적 시작 구조가 성립하려면 egress가 트랙보다 오래 살아야 했어요.
  • 후속 #85 (MAX_SESSIONS 재실측) — 송출이 실제로 켜지면 세션당 FFmpeg가 늘어나요.
  • 후속 예정 — 치지직 연동, 공개 범위 선택 UI, 클라이언트 API 계약 문서.

Closes #83

itzjb added 6 commits August 9, 2026 06:26
사용자별 RTMP 송출 대상을 도입하기 위한 인증·저장 계층이다(#83). 송출 대상을
서버 전역 환경변수 하나로 고정하던 구조를 사용자별로 바꾸는 작업의 전반부이며,
방송 라이프사이클 제어와 송출 시작·중지 배선은 후속 작업으로 남긴다.

streaming_accounts 테이블을 신설한다. 로그인 신원인 oauth_accounts와 분리한
것은 로그인에 쓰는 Google 계정과 송출할 채널의 계정이 다를 수 있기 때문이다.
provider CHECK에 chzzk 자리를 미리 넣어 두어 나중에 제약을 ALTER 하지 않도록
했고, GORM AutoMigrate 목록과 versioned SQL 양쪽을 함께 갱신했다.

연결은 두 단계다. POST /auth/youtube/connect 가 Bearer 인증으로 사용자를
확인하고 state 를 발급한 뒤 authorize URL 을 반환하면, 클라이언트가 그 URL 로
이동하고 콜백에서 state 로 사용자를 복원한다. 콜백은 브라우저 리다이렉트로
도착해 Authorization 헤더가 없으므로 state 가 유일한 사용자 바인딩 수단이다.
state 는 1회용이며 TTL 10분이다.

리프레시 토큰은 기존 ProviderTokenCipher 로 암호화해 저장하고 평문은 남기지
않는다. Apple 로그인이 비활성인 환경에서도 암호화기가 생성되도록 배선을
고쳤다. 액세스 토큰 갱신은 사용자 단위로 직렬화해 다중 세션에서 중복 갱신이
발생하지 않게 했다.

구현 전 수동 E2E 로 확인한 사실을 반영했다. 토큰 응답의 refresh_token_expires_in
이 7일이어서 만료 시각을 추적하는 컬럼을 두었고, 콜백에 문서에 없는 iss
파라미터가 함께 오므로 명시한 파라미터만 읽는다. 인가 코드에 슬래시가 포함되어
교환 시 form 인코딩이 필요하고, 채널이 없는 계정으로 연결을 시도하면 응답의
items 가 비어 오므로 이를 오류로 다룬다.
재사용 가능한 방송 스트림을 한 번 만들어 두고 재사용하려면 스트림 식별자와
ingest 주소를 보관해야 한다. 주소 4종은 공개 상수 성격이라 평문으로 두고,
스트림 이름은 방송 비밀번호에 해당하므로 리프레시 토큰과 동일하게 암호화해
저장한다. GORM 모델과 versioned SQL 양쪽을 함께 갱신했다.
브라우저 리다이렉트 대신 클라이언트가 구글에서 직접 받은 인가 코드를 서버로
넘기는 방식으로 바꿨다. 기존 로그인이 이미 같은 형태이고, 리다이렉트가 없으면
콜백 경로와 state 관리가 통째로 필요 없어진다.

코드 출처에 따라 교환 파라미터가 다르다. 네이티브 SDK가 준 코드는 redirect_uri
없이 교환되지만 웹 팝업이 준 코드는 postmessage를 요구한다. 실측으로 확인한
사실이라 출처를 요청 본문으로 받아 서버가 매핑한다. 출처를 생략하면 네이티브로
간주하므로 기존 계약과 호환된다.

웹 클라이언트가 팝업을 띄우려면 client_id와 스코프가 필요해 조회 엔드포인트를
두었다. client_id는 공개 식별자라 인증을 요구하지 않으며, 스코프를 서버가
내려주므로 클라이언트에 하드코딩이 남지 않는다.
플랫폼마다 송출 준비 절차가 다르다. YouTube는 방송과 스트림을 만들고 둘을
연결해야 하지만 치지직은 스트림 키 조회만으로 끝난다. 키 조회 수준으로
추상화하면 두 플랫폼이 한 구조에 들어가지 않으므로 준비와 종료라는
라이프사이클 단위로 인터페이스를 잡았다.

YouTube 구현은 방송 생성 시 자동 시작과 자동 종료를 켠다. 실측 결과 송출이
시작되면 별도 전환 호출 없이 13.7초 만에 라이브가 되고, 송출을 멈추면 57.6초
뒤 자동으로 종료됐다. 덕분에 상태를 폴링하며 전환을 재시도하는 비동기 처리가
필요 없다.

재사용 스트림은 계정 연결 시점이 아니라 첫 준비 시점에 만든다. 연결 시점에
만들면 라이브가 아직 활성화되지 않은 계정은 연결 자체가 실패하는데, 활성화에는
최대 24시간이 걸리므로 연결과 방송 가능 여부는 분리돼야 한다.

라이브 미활성 계정은 응답 사유가 livePermissionBlocked로 온다. 활성화 전후로
같은 호출을 실행해 확인한 값이며, 함께 오는 도움말 주소를 사용자 안내에 쓴다.
송출 대상이 서버 전역 환경변수 하나로 고정돼 있어 모든 세션이 운영자 채널
하나로만 나갔고, 동시 세션이 둘 이상이면 같은 키로 충돌했다. 이제 세션
소유자가 연결한 계정에서 대상을 가져오고, 송출 개시는 명시적 요청으로만
이루어진다. 뷰어 송출은 그대로 둔 채 플랫폼 송출만 끄는 것도 가능해졌다.

시작 요청은 트랙이 도착한 뒤에 오는데 기존 파이프라인은 egress를 인자로 고정해
잡고 있어 실행 중에 꽂을 방법이 없었다. 파이프라인이 슬롯을 통해 참조하도록
바꿔 시작이 채우고 중지가 비운다. 프레임당 원자적 읽기 한 번이 추가 비용이다.

ingest 주소는 API 응답값을 쓰므로 하드코딩 상수가 사라졌고, 그 과정에서 평문
RTMP 대신 RTMPS로 전환된다.

플랫폼이 서버에 구성되지 않은 경우 기존 501 응답을 유지해 인증을 끄고 도는
벤치마크와 미설정 배포의 동작을 보존한다.
로그인한 사용자에게 연결 버튼을 노출하고, 구글 팝업으로 받은 인가 코드를
서버로 넘긴다. 로그인 흐름은 그대로 두고 연결 동작만 추가했다.

client_id와 스코프는 서버 조회 엔드포인트에서 받아오므로 클라이언트에
하드코딩이 없다. 인가 코드는 브라우저를 거쳐 갈 뿐 저장되지 않으며 토큰과
스트림 키는 서버에만 남는다.
@chaeyn
chaeyn marked this pull request as ready for review August 10, 2026 08:33
@chaeyn
chaeyn requested a review from a team as a code owner August 10, 2026 08:33
@chaeyn
chaeyn merged commit e6675d6 into main Aug 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: YouTube Live API 연동으로 사용자별 RTMP 송출 대상 도입

2 participants